查看原文
其他

一个HTTPS转HTTP的Bug,他们竟然忍了2年?原谅我无法接受,加班改了!

二师兄 程序新视界 2022-08-05

今天这篇文章给大家讲一个追查Bug的故事和过程。个人一直认为:事出反常必有妖,程序中的Bug也是如此

希望通过这个Bug的排查故事,大家不仅能够学到一系列的知识点,同时也能学会如何解决问题,如何更加专业的做事。而解决问题的方式及思维比单纯的技术更加重要。

Let's go!

故事的起因

刚接手新团队新项目没多久,在发布一个系统时,同事友善的提醒:发布xx系统时,在测试环境要注释掉一行代码,上线发布时再放开注释。

听此友善提醒,一惊:这又是什么黑科技啊?!在我的经验里,还没有什么系统需要这样处理,暗下决心要排查此问题。

终于抽出时间,周五折腾了多半天,没解决掉,周末还心里惦记着,于是加班也搞定这个问题。

Bug的存在及操作

项目是基于JSP的,没有做前后端分离。在JSP页面中引入了一个公共的head.jsp,该文件内有这样一行代码和注释:

<!-- 解决线上HTTPS浏览器转圈的问题,测试环境要注释掉下面的一句话 -->
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests" />

同事友善提醒的就是注释上的操作,测试环境注释掉(不然无法访问),生产环境需要放开,不然也无法访问(转圈圈啊)。据注释说明,大概知道是用来解决HTTPS相关的问题。

那么,是什么原因导致了要这样操作?有没有更简单的操作?大家只是在这么做,没人寻找问题的根源,也没人能出答案,只能自己去寻找了。

HTTPS中的HTTP请求

先来看看配置META元素是干什么用的。

其中http-equiv指定的“Content-Security-Policy”就"网页安全政策",缩写CSP,常用来防止XSS攻击。

通常的使用方法就是在HTML中通过meta标签来进行定义:

<meta http-equiv="content-security-policy" content="策略">
<meta http-equiv="content-security-policy-report-only" content="策略">

其中,在content中可以指定涉及安全的各类限制策略。

项目中使用的upgrade-insecure-requests便是限制策略之一,作用是:自动将网页上所有加载外部资源的HTTP链接换成HTTPS协议

此刻稍微明白了一点,原来最初写这行代码是想将HTTP请求强制转换成HTTPS请求啊。

但正常情况来说,只要在Nginx或SLB中配置了HTTP转HTTPS便不会出现这类问题,而系统是有对应的配置的。

于是,在线上另起一个服务实验了一下,注释掉这段代码,部分功能还真的在转圈圈,诚不欺我!

为什么HTTPS中不允许HTTP请求

查看浏览器中的请求,发现转圈圈原来是如下错误引起的:

Mixed Content: The page at 'https://example.com' was loaded over HTTPS, but requested an insecure stylesheet 'http://example.com/xxx'. This request has been blocked; the content must be served over HTTPS.

其中,Mixed Content即混合内容。所谓的混合内容通常出现在以下情况:初始的HTML的内容是通过HTTPS加载的,但其他资源(比如,css样式、js、图片等)则通过不安全的HTTP请求加载。此时,同一个页面,同时使用了HTTP和HTTPS的内容,而HTTP协议会降低整个页面的安全性。

因此,现代浏览器会针对HTTPS中的HTTP请求进行警告,阻断请求,并抛出上述异常信息。

现在,问题的原因基本明确了:HTTPS请求中出现了HTTP请求。

那么,解决方案有几种:

  • 方案一:在HTML中添加meta标签,强制将HTTP请求转换成HTTPS请求。这也是上面的使用方式,但这种方式的弊端也很明显,在没有使用HTTPS的测试环境,需要手动的注释掉。否则,也无法正常访问。
  • 方案二:通过Nginx或SLB的配置,将HTTP请求转换成HTTPS请求。
  • 方案三:最笨的方法,找到项目中存在HTTP请求的问题,逐个修复。

初步改造,略显成效

目前使用的第一种方案很显然不符合要求,而第二种方案已经配置了,但部分页面依旧不起效。那么,还有其他方案吗?

经过大量排查,发现导致不起效的原因是:项目中大量使用了redirect方式的跳转。

@RequestMapping(value = "delete")
public String delete(RedirectAttributes redirectAttributes) {
  //.. do something
  addMessage(redirectAttributes, "删除xxx成功");
  return "redirect:" + Global.getAdminPath() + "/list";
}

redirect方式的跳转在HTTPS的环境下会重定向到HTTP协议,导致无法访问。

这也太坑了,难怪上面HTTP转HTTPS的设置都配置完成了,部分页面还不起效。

而导致这个问题的根本原因是Spring的ViewResolver对HTTP 1.0协议的兼容。

针对此问题,将其关闭即可解决,具体改造方案有两个。

方案一,将redirect改为RedirectView类来实现:

modelAndView.setView(new RedirectView(Global.getAdminPath() + "/list"truefalse));

其中RedirectView的最后一个参数设置为false,就是将http10Compatible的开关关闭,不对HTTP 1.0协议进行兼容。

方案二:配置Spring的ViewResolver的redirectHttp10Compatible属性。通过这种方案,可以实现全局关闭。

<bean id="viewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver">
  <property name="viewClass" value="org.springframework.web.servlet.view.JstlView" />
  <property name="prefix" value="/" />
  <property name="suffix" value=".jsp" />
  <property name="redirectHttp10Compatible" value="false" />
</bean>

由于项目中使用redirect较多,于是就采用了第二种方案。修改之后,发现大部分问题都解决了。

为了防止遗漏,就多点了一些页面,竟然还有漏网之鱼!

Shiro拦截器又作祟

解决了重定向导致的问题,以为万事大吉了,结果涉及到Shiro重定向的页面又出现了类似的问题。原因很简单:某些页面的权限验证需要经过Shiro,但Shiro将HTTPS请求拦截之后,重定向时转换成了HTTP请求。

那么,为什么视图层将redirectHttp10Compatible设置为false不起效呢?

追踪了Shiro拦截器中的代码,发现Shiro在拦截器中默认将redirectHttp10Compatible设置为true,又是一坑~

查看源码可以发现,Shiro的登录过滤器FormAuthenticationFilter的方法中调用了saveRequestAndRedirectToLogin方法:

protected void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException {
    saveRequest(request);
    redirectToLogin(request, response);
}

// 进而调用redirectToLogin方法
protected void redirectToLogin(ServletRequest request, ServletResponse response) throws IOException {
   String loginUrl = getLoginUrl();
   WebUtils.issueRedirect(request, response, loginUrl);
}

// 通过WebUtils.issueRedirect进行设置
public static void issueRedirect(ServletRequest request, ServletResponse response, String url) throws IOException {
    issueRedirect(request, response, url, (Map)null, truetrue);
}

// 通过WebUtils.issueRedirect重载方法
public static void issueRedirect(ServletRequest request, ServletResponse response, String url, Map queryParams, boolean contextRelative, boolean http10Compatible) throws IOException {
    RedirectView view = new RedirectView(url, contextRelative, http10Compatible);
    view.renderMergedOutputModel(queryParams, toHttp(request), toHttp(response));
}

通过上述代码追踪,可以看到,最终在WebUtils的issueRedirect方法中调用了两次issueRedirect,而http10Compatible参数值默认为true。

找到问题的根源,解决起来就简单了,重写FormAuthenticationFilter拦截器:

public class CustomFormAuthenticationFilter extends FormAuthenticationFilter {
 
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
     if (isLoginRequest(request, response)) {
            if (isLoginSubmission(request, response)) {
                return executeLogin(request, response);
            } else {
                return true;
            }
        } else {
            saveRequestAndRedirectToLogin(request, response);
            return false;
        }
    }
    
    protected void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException {
        saveRequest(request);
        redirectToLogin(request, response);
    }
    
    protected void redirectToLogin(ServletRequest request, ServletResponse response) throws IOException {
        String loginUrl = getLoginUrl();
        WebUtils.issueRedirect(request, response, loginUrl, null, truefalse);
    }
}

示例中,将onAccessDenied中需要原本调用WebUtils.issueRedirect方法的http10Compatible参数改为false即可。

上面只是示例,实际上不仅包括成功页面,还包括失败页面等,都需要重新实现一下对应的方法。最后,在shiroFilter中配置自定义的拦截器。

 <!-- 自定义的登录过滤器-->
 <bean id="customFilter" class="com.senzhuang.shiro.CustomFormAuthenticationFilter" />
 
 <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
  <property name="securityManager" ref="securityManager" />
  <property name="loginUrl" value="/login.html"></property>
  <property name="unauthorizedUrl" value="/refuse.html"></property>
  <property name="filters">
      <map>
          <entry key="authc" value-ref="customFilter"/>
      </map>
     </property>
 </bean>

经过上述的改造,关于HTTPS中的HTTP请求问题已经得到解决了。

为了防止遗漏,又挨个点了一些页面,又发了问题了!哎,咋那么手欠呢……

LayUI的坑

本来以为解决了上面的问题,就彻底解决了,可以吃顿烧烤庆祝一下了。结果,在前端页面中又发现了类似的错误。但此时错误信息来自访问登录页面的路径:

http://example.com/a/login

奇了怪了,已经登录成功了,为什么业务操作页面还会再请求login页面呢?而且跳转过去还是HTTP请求,而不是HTTPS的请求。

查看了一下login的请求结果:

303错误

排查了相关的业务代码,登录完成之后,再也没有请求登录请求了啊,为什么会再次请求一次login呢?难道是访问某些资源受限,导致重定向到登录页面了?

于是,查看了一下HTML调用的”Initiator“:

Initiator

原来是LayUI请求对应的layer.css资源时,触发了login的登录操作。

首先想到的是Shiro中没有放开静态资源的拦截,于是在Shiro中放开了layui的拦截权限,但问题已经存在。

再次排查,发现页面中没有主动引入layer.css文件,于是主动引入了layer.css文件,但问题还是存在。

没办法,只好查看layui.js,看看为什么要发起这个请求。此时,还留意到请求路径中有一个"undefinedcss"的词。

用过js的朋友都知道,undefined是js中变量未初始化的默认值,类似Java中的null。

在layui.js中搜索”css/“,还真找到这样一段代码:

return layui.link(o.dir + "css/" + e, t, n)

对照起来,也就是说o.dir的值为"undefined",与后面的css连接起来就变成了"undefinedcss",而这个路径并不存在,也没在Shiro中进行权限配置,默认会走到登录界面去。而这里是内部的一个异步的redirect请求,不会在页面呈现,要查看浏览器的错误信息才能发现。

找到问题原因了,改造起来就简单了,将layui的link方法参数进行修改:

// 注释掉
// return layui.link(o.dir + "css/" + e, t, n)

// 改为
return layui.link((o.dir ? o.dir:"/static/sc_layui/") +"css/"+e, t, n)

改造的基本思路是:如果o.dir有值(js中有值即为true)则使用o.dir的值;如果o.dir为undefined则采用指定的默认值。

其中"/static/sc_layui/"为项目中存放layui组件的路径。由于layui.js可能是压缩后的js,可通过搜索”css/“或”layui.link“找到对应的代码。

重启项目,清除浏览器缓存,再次访问页面,问题得到彻底解决。

可以安心吃烤串了

周末又花了半天时间,终于把这个问题彻底解决了,现在可以安心去吃顿烤串庆祝一下了。

最后,回顾一下这个过程,看看你能从中收获到什么:

  • 出现问题:不同环境(HTTP和HTTPS)需要手动改代码;
  • 寻找问题:为了安全,HTTPS内不允许发起HTTP请求;
  • 解决问题:两种方式关闭http10Compatible
  • Shiro问题:Shiro中默认为关闭http10Compatible,重写Filter,实现关闭操作;
  • LayUI Bug修复:LayUI代码bug,导致发起http(登录)请求。修复此Bug;

在这个过程中,如果你只是安于现状,”遵守规则“,每次上线时修改一下文件,不仅费时费力,而且不知为什么要这么做。

但如果像笔者一样,刨根问底的追踪一下,你将会学到一系列的知识:

  • HTTP请求的CSP,upgrade-insecure-requests配置;
  • HTTPS中为什么不能发起HTTP请求;
  • Spring视图解析器中配置http10Compatible
  • redirect方式视图返回的弊端;
  • Nginx中如何将HTTP请求转为HTTPS请求;
  • HTTP请求的混合内容(Mixed Content)概念及错误;
  • HTTP 1.0、HTTP 1.1、HTTP2.0协议的区别;
  • Shiro拦截器自定义Filter;
  • Shiro拦截器过滤指定URL访问;
  • Shiro拦截器的配置及部分源码实现;
  • LayUI的一个bug;
  • 其他排查该问题时用到或学到的技术;

这些技术你学到了吗?解决问题的思路和方式方法你学到了吗?如果本文有那么一点内容启发到你了,我不吝分享,你也不要吝啬,点个赞吧。


往期推荐

隐藏了2年的Bug,终于连根拔起,悲观锁并没有那么简单

还在用new Date计算任务执行时间?强烈建议使用这个API!

90%的人(包括我)都以为会用ThreadPoolExecutor了,看了这10张图再说吧!

弄懂“三门问题”,成功概率翻倍,来用代码验证一下

不知道Mysql排序的特性,加班到12点,认了认了!

加锁了还有并发问题?Redis分布式锁,真的用对了?



如果你觉得这篇文章不错,那么,下篇通常会更好。添加微信好友,可备注“加群”(微信号:zhuan2quan)

一篇文章就看透技术本质的人,
  和花一辈子都看不清的人,
  注定是截然不同的搬砖生涯。
▲ 按关注”程序新视界“,洞察技术内幕

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存